Skip to content

fix(verify): enforce anti-spoof block + EAR + aged-threshold + SHA-pin + verify-challenge (2026-05-12 ML review)#102

Merged
ahmetabdullahgultekin merged 2 commits into
mainfrom
fix/2026-05-12-liveness-and-puzzles
May 27, 2026
Merged

fix(verify): enforce anti-spoof block + EAR + aged-threshold + SHA-pin + verify-challenge (2026-05-12 ML review)#102
ahmetabdullahgultekin merged 2 commits into
mainfrom
fix/2026-05-12-liveness-and-puzzles

Conversation

@ahmetabdullahgultekin

Copy link
Copy Markdown
Contributor

Summary

Closes 4 P0 + 1 P1 from the 2026-05-12 ML review:

  • Bug 1 (P0) — AntispoofPipelineAssembler recommended_action='block' is now enforced. New ANTISPOOF_BLOCK_ENFORCE=true flag (default ON) returns HTTP 403 instead of an advisory 200/verified=True.
  • Bug 2 (P0) — Wired single-frame EAR liveness check using the 2026-05-11 spoof-detector blink-cache/EAR work. New ANTISPOOF_EAR_VETO_ENABLED flag (default OFF — operator opts in once models/face_landmarker.task is deployed).
  • Bug 3 (P0)VERIFICATION_THRESHOLD_AGED semantics fixed: was 0.38 (stricter than 0.45 default — opposite of intent), now 0.55 (more lenient under distance < threshold comparator). Pydantic validator hard-rejects aged < standard at config-load.
  • Bug 4 (P1) — New POST /liveness/verify-challenge endpoint for the web biometric-puzzles training surface; structural validation (action enum, timestamp monotonicity, duration sanity, confidence floor). Companion web-app PR wires it.
  • Bug 5 (P1)DEEPFACE_SHA256_REQUIRED=true default in prod: empty pin now raises at model-load (was warn-and-skip). Pinned Facenet512 SHA256 = 3f76b5117a9ca574d536af8199e6720089eb4ad3dc7e93534496d88265de864f (captured from running container; goes into ops-managed .env.prod).

Companion PRs:

  • web-app: fix/2026-05-12-liveness-and-puzzles
  • spoof-detector: fix/2026-05-12-expose-blink-analyzer-shim

Test plan

  • pytest tests/unit/test_verification_threshold_aged.py — 4 pass
  • pytest tests/unit/test_deepface_sha256_required.py — 5 pass
  • pytest tests/integration/test_verify_antispoof_block_enforce.py — 8 pass
  • pytest tests/integration/test_verify_challenge_endpoint.py — 7 pass
  • pytest tests/integration/test_verify_antispoof_wiring.py — 6 pass (existing; baseline-rot mock now also applied)
  • pytest tests/unit/test_config_validator.py — 14 pre-existing tests still green
  • Operator: pin DEEPFACE_FACENET512_SHA256 in .env.prod (already added to local file, not committed because gitignored)
  • Operator: rebuild bio container to pick up changes
  • Operator: decide whether to canary-roll ANTISPOOF_BLOCK_ENFORCE=false first or accept default-ON
  • Operator: when ready, deploy models/face_landmarker.task + flip ANTISPOOF_EAR_VETO_ENABLED=true

44 of 44 added/touched tests pass locally with DATABASE_URL=postgresql://test:test@localhost:5432/test. The bio baseline 79-failing-tests rot was untouched (out of scope).

🤖 Generated with Claude Code

…pin SHA, add verify-challenge

Closes 4 P0/P1 findings from the 2026-05-12 ML review:

Bug 1 (P0) — Anti-spoof `recommended_action='block'` is advisory
  AntispoofPipelineAssembler attached `recommended_action='block'` to /verify
  responses but the route still returned 200/verified=True. Added
  `ANTISPOOF_BLOCK_ENFORCE=true` (default ON in prod). When any layer votes
  block (face_usability_block, hybrid_fusion_is_spoof, or recommended_action='block')
  the route now raises HTTP 403 with `{error_code: ANTISPOOF_BLOCKED, reason: <category>}`.
  Flip flag false for canary/observation rollout. Tests:
  tests/integration/test_verify_antispoof_block_enforce.py (8 assertions, 4 for Bug 1).

Bug 2 (P0) — Blink-cache / EAR work unreachable from /verify
  The 2026-05-11 spoof-detector paper-P0 (blink cache + EAR recalibration)
  lived in `src.infrastructure.analyzers.blink_analyzer` but was never wired
  into the route. Added `_evaluate_ear_liveness_safe()` that runs MediaPipe
  FaceLandmarker on the uploaded still frame, computes EAR via the
  spoof-detector library (EAR_THRESHOLD=0.18), and vetoes on closed eyes.
  Multi-frame BlinkAnalyzer state (V-shape detection) is explicitly out of
  scope here — the cache only helps with multi-face/frame video sessions
  that the current /verify single-still-frame contract doesn't provide.
  Default OFF (ANTISPOOF_EAR_VETO_ENABLED) until ops deploys the
  face_landmarker.task asset; helper fails-soft to None when the model
  or MediaPipe is missing. Companion spoof-detector PR exposes the
  blink_analyzer module on the public `spoof_detector.*` namespace
  (per `feedback_spoof_detector_architecture`, algorithms live there).

Bug 3 (P0) — VERIFICATION_THRESHOLD_AGED semantics inverted
  Comparator is `verified = distance < threshold`; default was
  THRESHOLD=0.45, THRESHOLD_AGED=0.38 — making aged users *stricter*
  (higher FRR), the opposite of the adaptive feature's intent. Raised
  THRESHOLD_AGED default to 0.55 (still well below Facenet cosine
  operating-point ceiling ~0.6 so FAR stays controlled). Added a
  Pydantic model_validator that hard-rejects aged < standard at
  config-load — the regression cannot silently come back via env-file
  edits. .env.example documents the comparator semantics inline.
  Tests: tests/unit/test_verification_threshold_aged.py (4 assertions).

Bug 4 (P1) — Web puzzles call onSuccess client-side, no server validation
  Added POST /api/v1/liveness/verify-challenge for the web
  biometric-puzzles training surface. Single-action contract:
  `{action, start_timestamp_ms, end_timestamp_ms, confidence, ...}` →
  `{verified, action, duration_seconds, reason_code, message}`. Structural
  validation only (action enum, timestamps monotonic + sane duration
  120ms..60s, confidence floor 0.5). Heavier server-side detection
  belongs to multi-step /liveness/verify. Tests:
  tests/integration/test_verify_challenge_endpoint.py (7 assertions).
  Web-app wiring lands in a companion PR on web-app.

Bug 5 (P1) — SHA256 model integrity pins empty / advisory
  `_verify_model_integrity` previously logged a WARNING when the pin was
  empty. Added `DEEPFACE_SHA256_REQUIRED=true` (default). With this flag
  on AND ENVIRONMENT=production, an empty pin now raises RuntimeError at
  model-load — defense against silent ~/.deepface/weights/ rotations.
  Operator action: compute `sha256sum` against the in-container
  facenet512_weights.h5 and pin it via DEEPFACE_FACENET512_SHA256 in
  .env.prod (captured 2026-05-12 from running container:
  3f76b5117a9ca574d536af8199e6720089eb4ad3dc7e93534496d88265de864f).
  The face/hand_landmarker.task hashes intentionally stay empty —
  those models are NOT loaded server-side; the server only delivers
  them as static SHA256-verified assets to clients. Tests:
  tests/unit/test_deepface_sha256_required.py (5 assertions).

Test results (DATABASE_URL=postgresql://test:test@localhost:5432/test):
  - 4 new unit tests (verification_threshold_aged)
  - 5 new unit tests (deepface_sha256_required)
  - 8 new integration tests (verify_antispoof_block_enforce)
  - 7 new integration tests (verify_challenge_endpoint)
  - 6 pre-existing integration tests (verify_antispoof_wiring) — now also
    run locally thanks to added `resemblyzer` mock (baseline-rot fix).
  - test_config_validator.py — 14 pre-existing tests still green.
  Total: 44 pass / 0 fail locally.

Operator action items:
  1. Pin `DEEPFACE_FACENET512_SHA256` in /opt/projects/fivucsas/biometric-processor/.env.prod
     with the value captured above (already added to local .env.prod, NOT committed
     because .env.prod is gitignored).
  2. Rebuild biometric-processor container to pick up these changes.
  3. Decide whether to flip `ANTISPOOF_BLOCK_ENFORCE=false` for a canary rollout
     before relying on the default-ON behavior.
  4. To enable Bug 2 EAR veto: deploy `models/face_landmarker.task`, set
     `FACE_LANDMARKER_MODEL_PATH`, then `ANTISPOOF_EAR_VETO_ENABLED=true`.
  5. Add the identity-core-api proxy for `/biometric/puzzles/verify-challenge`
     when convenient — web-app soft-passes on 404 until it lands.

Memory rules respected:
  - feedback_spoof_detector_architecture: algorithms come from spoof-detector
    via the new public shim; biometric-processor only imports + wires.
  - feedback_liveness_hybrid_vs_passive: no liveness backend changes; prod
    LIVENESS_BACKEND remains as configured by ops.
  - feedback_readonly_rootfs_cache_dirs: new lazy FaceLandmarker init
    respects the existing FACE_LANDMARKER_MODEL_PATH env contract; cache
    dirs unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@ahmetabdullahgultekin

Copy link
Copy Markdown
Contributor Author

Merge order note (2026-05-12)

Hard dependency: spoof-detector #18 must merge first — this PR imports BlinkAnalyzer + compute_ear from the new public spoof_detector.* namespace. CI ImportError will fire if the submodule pointer hasn't advanced past spoof-detector#18's merge commit.

Suggested sequence:

  1. shim: expose BlinkAnalyzer + compute_ear via spoof_detector public namespace spoof-detector#18
  2. This PR (biometric-processor fix(verify): enforce anti-spoof block + EAR + aged-threshold + SHA-pin + verify-challenge (2026-05-12 ML review) #102) — bump submodule pointer in the merge commit
  3. fix(biometric-puzzles): wire FacePuzzle + HandGesturePuzzle to server validation (Bug 4, 2026-05-12) web-app#90 — server validation for puzzles (soft-passes until both above land + the identity-core-api proxy ships)

Operator coordination (SHA pin, container rebuild, ANTISPOOF_BLOCK_ENFORCE canary, EAR model deploy, identity-core-api proxy follow-up) is in parent Rollingcat-Software/FIVUCSAS#67 OPERATOR_ACTIONS_2026-05-12.md items 6–10.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@ahmetabdullahgultekin ahmetabdullahgultekin merged commit 5cb1e4a into main May 27, 2026
8 of 10 checks passed
ahmetabdullahgultekin added a commit that referenced this pull request May 28, 2026
#104)

Closes the 4th recurrence of feedback_readonly_rootfs_cache_dirs
(DeepFace + Numba + UniFace, now MiniFASNet). With read_only:true rootfs
and the cache named volume owned by root:root, DeepFace running as uid
100 silently failed to download MiniFASNet weights on first inference,
collapsing the anti-spoof verdict to a false-positive. Today's hot-fix
manually docker-cp'd the .pth files into the live volume; that fix was
load-bearing on operator memory and would have vanished on the next
`docker volume rm`.

Defense in depth, two layers:

1. Image bake-in. New `model-fetcher` build stage downloads the four
   critical weight files with SHA256 verification:
   - facenet512_weights.h5          3f76b51...
   - centerface.onnx                77e394b...
   - 2.7_80x80_MiniFASNetV2.pth     a5eb02e...
   - 4_0_0_80x80_MiniFASNetV1SE.pth 84ee1d3...
   All four match upstream (serengil/deepface_models, Star-Clouds/CenterFace,
   minivision-ai/Silent-Face-Anti-Spoofing) and the running container's
   live SHAs. COPY'd into the runtime stage at /opt/baked-models/.deepface
   with --chown=100:101.

2. Entrypoint shim (deploy/entrypoint.sh). Runs as root, chowns any
   externally-mounted /tmp/.deepface cache volume to 100:101, seeds
   missing weight files from the baked /opt/baked-models layer (so a
   wiped named volume self-heals on next boot), then drops to uid 100
   via gosu before exec'ing the CMD. Idempotent + best-effort. Pins
   the app user UID/GID to 100/101 explicitly so host-side chown matches
   across rebuilds (the previous --system numbering was implicit and
   drifted).

Companion changes:
- .env.example documents DEEPFACE_FACENET512_SHA256 (required runtime
  pin per PR #102 `DEEPFACE_SHA256_REQUIRED=true`) plus the three other
  SHAs for audit reference.
- docker-compose.prod.yml comments document that the `biometric_models`
  volume is now self-healing and `docker volume rm` is safe (operator
  no longer has to remember the manual docker-cp dance).

Coordinated with parent PR (OPERATOR_ACTIONS_2026-05-12.md item 11)
which gives the post-merge cleanup runbook.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ahmetabdullahgultekin added a commit that referenced this pull request May 28, 2026
…106)

The mediapipe.solutions namespace was removed in mediapipe 0.10.35
(currently deployed in production). Every /verify call was logging
"Landmark detection failed: module 'mediapipe' has no attribute
'solutions'" and all temporal/face signals (EAR, MAR, yaw, pitch, roll,
blink, smile) collapsed to null in the liveness_calibration event.

Ports four server-side consumers from the legacy face_mesh API to the
new mp.tasks.vision.FaceLandmarker API:

  app/infrastructure/ml/landmarks/mediapipe_landmarks.py
  app/infrastructure/ml/quality/quality_assessor.py
  app/infrastructure/ml/proctoring/mediapipe_gaze_tracker.py    (VIDEO mode)
  app/infrastructure/ml/liveness/active_liveness_detector.py
  tests/demo_local.py                                            (test harness)

Result-shape adaptations:
  Old: result.multi_face_landmarks[0].landmark[i].x
  New: result.face_landmarks[0][i].x

The Tasks API requires a .task model asset. Centralised the
path-resolution + SHA256 integrity check in a new shared loader so the
four call sites + the verification route's _evaluate_ear_liveness_safe
(PR #102) follow one contract:

  app/infrastructure/ml/landmarks/face_landmarker_loader.py
    - FACE_LANDMARKER_MODEL_PATH override (env-var first, repo-root fallback)
    - FACE_LANDMARKER_MODEL_SHA256 verification (warn-and-disable on mismatch)
    - VIDEO running-mode with monotonic timestamp_ms (gaze tracker)
    - IMAGE running-mode (the other three)

Model delivery:
  Dockerfile bakes face_landmarker.task (float16/latest, ~3.7 MB) into
  /app/models/ via curl with build-time SHA256 verification. PIN:
    64184e229b263107bc2b804c6625db1341ff2bb731874b0bcc2fe6544e0bc9ff

  Documented in .env.example. Configured first-class in app/core/config.py
  (FACE_LANDMARKER_MODEL_PATH, FACE_LANDMARKER_MODEL_SHA256 already declared
  per PR #102; this PR adds the path default + the live SHA reference).

Tests:
  + 22 new unit tests (loader path/SHA/import fail-soft, ported sites
    asserting new result shape adaption, gaze tracker VIDEO-mode timestamp
    monotonicity)
  + 1 new integration test that AST-walks app/ for any executable
    reference to mp.solutions / mediapipe.solutions and fails the build
    if any survive (regression guard).

Verified with `grep -rn "mp.solutions" app/ tests/` on branch HEAD: zero
executable references; all hits are docstrings/comments documenting the
migration.

Depends on PR #102 for the FACE_LANDMARKER_MODEL_SHA256 settings field +
the _evaluate_ear_liveness_safe loader pattern this PR generalises. No
conflict in the shared verification.py path (that route is unchanged by
this PR).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants